7 Complex Custom Animations¶
By now, you can see that creating more complex animations in SwiftUI relies on understanding how the SwiftUI protocols and animation engine work. Done correctly, your custom animations still use SwiftUI to handle as much work as possible.
To create more complex animations, you often need to combine several elements working together. One way to produce a more complex animation is to combine view transitions with animated state changes. Animating the appearance and removal of a view while animating a state change can make a view stand out and clarify the relationship between new elements on a view.
In the previous chapter, you worked on adding animations to your custom views. Up to this point, your animations were limited to relying on a single property, but SwiftUI also supports animating multiple property changes within the same view. In this chapter, you’ll create a view that supports five independently animated values.
First, you’ll look at how to combine transitions and animations to produce a unified animation.
Adding a Popup Button¶
Open the starter project for this chapter. You’ll see the tea brewing app you worked with in the previous chapter with a few added features. Since tastes in tea can vary, the app now lets users customize the brew settings. They can also record their review of the results of each brew to help them find the perfect process to match their taste for each tea.
Open TimerView.swift. You’ll see the timer is now at the top of the view to make it easier to see. The timer also adds a slider to let the user adjust the brewing length.
Further down, you’ll see the familiar information showing the suggested brewing temperature and a slider that lets the user adjust the amount of water so the app can provide a suggested amount of tea. You’ll now add a button so the user can adjust the suggested ratio of tea to water.
Create a new SwiftUI view file inside the Timer folder named PopupSelectionButton.swift. Add the following properties to the generated view:
@Binding var currentValue: Double?
var values: [Double]
These properties provide a binding that passes the selection back from the view. It also allows passing in an array of Double
values that can be selected. Replace the preview body with:
PopupSelectionButton(
currentValue: .constant(3),
values: [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0]
)
This code provides the view sample settings. Update the view’s body to:
Group {
if let currentValue {
Text(currentValue, format: .number)
.modifier(CircledTextToggle(backgroundColor: Color("Bourbon")))
} else {
Text("\(Image(systemName: "exclamationmark"))")
.modifier(CircledTextToggle(backgroundColor: Color(.red)))
}
}
This code attempts to unwrap the currentValue
binding property. If successful, the value will display using the color bourbon for the background. If not, the view will show an exclamation mark with a red background. You wrap the conditional inside a Group
so you can apply additional modifiers to the two view states without repeating code. The CircledTextToggle
view modifier is identical to the CircledText
view modifier, except it applies a fixed frame to the Text. Without adding this frame, the changing size of the Text
view when transitioning from text to a system image would cause the view to shift.
Since you provided the preview a value of 3, you’ll now see the result, which shows the numeral three with the bourbon color background.
Your button shows the value but doesn’t let the user change it. You’ll implement that in the next section.
Adding Button Options¶
Add the following property after values
:
@State private var showOptions = false
This state property stores whether the view should show the options. To toggle it, add the following modifier to Group
:
.onTapGesture {
showOptions.toggle()
}
When the user taps the view, you toggle showOptions
. Now you need to show the user the options. You’ll lay out the options in an arc starting above the button. Add the following methods after the body:
private func xOffset(for index: Int) -> Double {
// 1
let distance = 180.0
// 2
let angle = Angle(degrees: Double(90 + 15 * index)).radians
// 3
return distance * sin(angle) - distance
}
private func yOffset(for index: Int) -> Double {
let distance = 180.0
let angle = Angle(degrees: Double(90 + 15 * index)).radians
return distance * cos(angle) - 45
}
Here’s how these two methods create the arc layout:
- You set
distance
to 180 for the radius of a circle. You’ll lay the buttons along this circle. - You want each button rotated 15 degrees from the previous one, so you multiply the index by 15. You then add 90 to this value which rotates the element’s location a quarter turn counter-clockwise. Note this difference from SwiftUI rotations. In a SwiftUI rotation, an increase in the angle rotates further clockwise. You then convert the angle from degrees to radians.
- Then you multiply the
distance
by the sine of the angle. The Swiftsin
function expects the angle in radians, which you converted to in the previous step. You then subtract thedistance
from this value which shifts the circle’s center to the left. As a result, thex
offsets start in line with the button and then decrease as the angle increases.
The vertical offset calculation works the same, except you use a cosine since you’re dealing with the y
value. You subtract 45 to shift the circle’s center to just above the button. With those methods to calculate each view’s position, you can now show the options in the view.
Wrap the current Group
inside a ZStack
by holding down Command and clicking the Group
view. Then select Embed in ZStack from the menu. A ZStack
overlays its views, with each view lying above the previous views in the stack. Since you want to overlay these options, this is perfect.
Now add the following code to the start of the Group
:
// 1
if showOptions {
// 2
ForEach(values.indices, id: \.self) { index in
// 3
Text(values[index], format: .number)
.modifier(CircledText(backgroundColor: Color("OliveGreen")))
// 4
.offset(
x: xOffset(for: index),
y: yOffset(for: index)
)
// 5
.onTapGesture {
currentValue = values[index]
showOptions = false
}
}
Text("\(Image(systemName: "xmark.circle"))")
.transition(.opacity.animation(.linear(duration: 0.25)))
.modifier(CircledTextToggle(backgroundColor: Color(.red)))
}
Here’s what this code does:
- You’ll only show the options when
showOptions
istrue
. - You iterate through the
indices
property of thevalues
array to get the index of each element in the array. - Then, you show the value using the initializer
Text
that allows passing a format. Using thenumber
format also displays the value concisely with only the minimum digits needed to reflect the value. - You offset each option vertically using the methods you just added to the view.
- When the user taps one of the options, you set the
currentValue
binding to the value and then setshowOptions
tofalse
to hide the options.
You now have an implementation you can use in your app. Open BrewInfoView.swift, which contains the view showing the suggested amount of tea for a given amount of water. Find the last Text
element in the VStack
and replace it with the following:
HStack(alignment: .bottom) {
Text("\(teaToUse.formatted()) teaspoons")
.modifier(InformationText())
Spacer()
PopupSelectionButton(
currentValue: $waterTeaRatio,
values: [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0]
)
}
You added the new PopupSelectionButton
view to let the user select the desired ratio and provide several options between 1.0 and 5.0.
Now run the app. Select any tea, tap the button and change the ratio. Observe how the suggested amount of tea changes to match the new ratio. Adjust the amount of water and observe that the tea adjusts to fit.
While the buttons show, they appear suddenly. In the next section, you’ll animate the appearance of the options.
Animating the Options¶
Since an animation requires a state change, your first thought might be to animate using the showOptions
already in place. If you try that, you’ll find a problem. Changing showOptions
causes SwiftUI to add or remove views. If you recall, you need a special type of animation called a transition to animate the appearance or removal of views.
You might consider triggering the animation based on the same state change, but that can be complicated. Instead, you’ll introduce a new property to manage the animation.
Back in PopupSelectionButton
, add the following new property after the existing ones:
@State private var animateOptions = false
You’ll use this property to manage the view appearance and removal independently of each other. Now update your offset(x:y:)
, under comment four, to:
.offset(
x: animateOptions ? xOffset(for: index) : 0,
y: animateOptions ? yOffset(for: index) : 0
)
Now you only offset the views when animateOptions
is true. Otherwise, they would remain hidden under the main button since they appear earlier in the ZStack
. Changing animateOptions
animates the buttons so they appear behind the main button and move to their final positions.
Next, update the code inside the outer onTapGesture
attached to the Group
to:
// 1
withAnimation(.easeOut(duration: 0.25)) {
animateOptions = !showOptions
}
// 2
withAnimation { showOptions.toggle() }
You’ll run two animations separately based on the current value of showOptions
.
Here’s how the code manages these changes:
- You wrap the change of
animateOptions
insidewithAnimation(_:_:)
using the ease-out animation with a duration of 0.25 seconds. You setanimationOptions
to the opposite ofshowOptions
— ifshowOptions
is currently true, it means you need to hide the options, and vice-versa. - You use a separate
withAnimation(_:_:)
to toggleshowOptions
. You do not specify an animation since this will trigger a transition applied to the view.
Recall that you also hide the options when the user selects an option. You need to update that code to match these changes. Replace the onTapGesture
closure inside the ForEach
loop with:
.onTapGesture {
currentValue = values[index]
withAnimation(.easeOut(duration: 0.25)) {
animateOptions = false
}
withAnimation { showOptions = false }
}
You’re using the same code you did earlier, except you know you’re hiding the options. As the last step, you’ll add a transition to the view.
Go to the Text
view at the top of the ForEach
loop. Add the following modifier after the view and before all the other modifiers:
.transition(.scale.animation(.easeOut(duration: 0.25)))
By default, the scale animation scales from and to a vanishing point at the center of the view. You apply an ease-out animation with a duration of 0.25 seconds to the transition, which matches the animation used with the change of offset position. Using the same animation keeps the two in sync, so they act as a single combined animation instead of separate animations.
Run the app, select a tea and tap the button.
Now you’ll see the options slide out from under the original button.
You’ve created an animated popup button combining transitions and state animation. As with most animations, it only animates a single element, the position. In the next section, you’ll learn about animating a view with multiple properties.
Animating Multiple Properties¶
In Chapter 6: Intro to Custom Animations, you learned about the Animatable
protocol and used it to produce views that could handle animations beyond what SwiftUI can handle by default. The changing number and sliding number animations you built in those chapters only dealt with a single changing value. In this section, you’ll create a view with five parameters that are fully animated.
The app shows you the past ratings of your brews. While the list shows the information, it would be nice to provide a visualization to help clarify the relationships between the different settings and the results. To do this, you’ll create a radar chart: a visualization to compare the characteristics of multiple values by plotting the data as a polygon, with each corner of the polygon representing one value. A radar chart looks like this:
Run the app and select Green Tea or Oolong Tea, which already have ratings. At the bottom of the view, you’ll see the ratings listed. Tap anywhere in that window, and you’ll see a sheet showing the first rating. You can quickly swipe between the ratings.
You’ll now create a visualization that reflects the values in these ratings.
Creating a Radar Chart¶
Create a new SwiftUI view file named AnimatedRadarChart.swift under the RadarChartgroup. Add the following properties to the new view:
var time: Double
var temperature: Double
var amountWater: Double
var amountTea: Double
var rating: Double
These are the five properties that your radar chart will show. For greater precision during the animation, you use a Double
type for each. Now update the preview to provide the values. Change the body of the preview to:
AnimatedRadarChart(
time: Double(BrewResult.sampleResult.time),
temperature: Double(BrewResult.sampleResult.temperature),
amountWater: BrewResult.sampleResult.amountWater,
amountTea: BrewResult.sampleResult.amountTea,
rating: Double(BrewResult.sampleResult.rating)
)
Now add the following computed property to the view:
var values: [Double] {
[
time / 600.0,
temperature / 212.0,
amountWater / 16.0,
amountTea / 16.0,
rating / 5.0
]
}
This computed property takes the individual values, turns them into an array you can loop over and handles the problem of scaling the chart by dividing each value by the maximum expected value. This step turns each value into a fraction between zero and one and ensures that charts from different measurements are comparable.
Now you’ll work on the chart to show these values. Replace the body of the view with:
// 1
ZStack {
// 2
GeometryReader { proxy in
// 3
let graphSize = min(proxy.size.width, proxy.size.height) / 2.0
let xCenter = proxy.size.width / 2.0
let yCenter = proxy.size.height / 2.0
}
}
This code makes some calculations you need to match the size of the chart to the size of the view. Here’s what it does:
- You’ll add more to this chart later in this chapter, so you build the view within a
ZStack
, which overlays child views. - Using a
GeometryReader
causes the views in the closure to take up as much space as possible and allows you access to information about the view’s size. You’ll use this information to scale the chart within the view. - You can calculate some values you’ll use later from the
GeometryProxy
passed to the closure of theGeometryReader
. You determine which is smaller: the view’s vertical or horizontal size. Then you divide it by two to determine the number of points to display when a value is at the maximum value. To help center the chart within the view, you calculate the center points in each position by dividing the width and height by two.
Now add the following code to the end of the GeometryReader
:
// 4
ForEach(0..<5 id: \.self) { index in
Path { path in
path.move(to: .zero)
path.addLine(to: .init(x: 0, y: -graphSize * values[index]))
}
// 5
.stroke(.black, lineWidth: 2)
// 6
.offset(x: xCenter, y: yCenter)
// 7
.rotationEffect(.degrees(72.0 * Double(index)))
}
Here’s what the code does:
- You loop between 0 and 4, since
values
has 5 elements. Remember,values
contains a scaled value between zero and one for each item to show on the chart. You then create a path that begins at the zero point and adds a line from that point. In SwiftUI, a negative value indicates a position upward in the view. To create a vertical upward line, you multiply the negative of thegraphSize
value computed earlier by the fraction of the current point. - You draw the path on the view using
stroke(_:lineWidth:)
, which draws a black line of width two. - The origin of a drawing in a SwiftUI view is at the leading top corner by default. To shift this to the center of the view, you apply
offset(x:y:)
, passing the center locations you calculated in step three. - You want to produce five equally spaced lines around the center point. You divide the 360 degrees of a full circle by five to find that you should rotate each line 72 degrees from the previous one. Since an increased number rotates clockwise in SwiftUI, succeeding lines will appear clockwise from the first.
You’ll see the plotted values in the preview.
With the basics in place, you’ll fill out the rest of the chart in the next section.
Adding Grid Lines¶
Now you’ll add a guide to each value. Inside the ForEach
loop, in front of the existing Path
, add:
Path { path in
path.move(to: .zero)
path.addLine(to: .init(x: 0, y: -graphSize))
}
.stroke(.gray, lineWidth: 1)
.offset(x: xCenter, y: yCenter)
.rotationEffect(.degrees(72.0 * Double(index)))
This code is identical to the previous code, except it doesn’t scale the height of the line so that it’s the entire length. You also stroke the line in gray and one point wide. Since it occurs before plotting the value, the plotted value will overlay it.
Add the following code after the assignment of yCenter
and before the current ForEach
:
// 1
let chartFraction = Array(stride(
from: 0.2,
to: 1.0,
by: 0.2
))
ForEach(chartFraction, id: \.self) { fraction in
// 2
Path { path in
path.addArc(
center: .zero,
radius: graphSize * fraction,
startAngle: .degrees(0),
endAngle: .degrees(360),
clockwise: true
)
}
// 3
.stroke(.gray, lineWidth: 1)
.offset(x: xCenter, y: yCenter)
}
This code produces grid lines for the chart that help the reader interpret the values.
Here’s what the lines do:
- You loop through a set of fractions that evenly divide the chart into five sections. SwiftUI will pass the value to the closure as
fraction
. - For each value, you create a path and add an arc to the path. This arc will sweep around the center of the view with a radius of
graphSize
multiplied by the currentfraction
. You turn the arc into a circle by setting the start and end angles to sweep the full 360 degrees. This loop will draw a series of larger arcs as SwiftUI iterates over the values. - You stroke each path as a gray line with a width of one point. As before, you use
offset(x:y:)
to set the center point to the center of the view.
Look at your chart in the preview. Adding the grid lines makes it easier to interpret each value.
In the next section, you’ll add a bit of color to the graph and add it to the app.
Coloring the Radar Chart¶
The chart looks a little dull in shades of gray. To add some color, add the following code before the body of the view:
let lineColors: [Color] = [.black, .red, .blue, .green, .yellow]
This constant defines a set of colors in an array. If you take them in the same order as the values in the chart, you’ll notice the colors relate to the measurements: black for the time, red for temperature, blue for the amount of water, green for the amount of tea and yellow for the rating.
With this array, you can add color to the chart’s values. Look for the line that reads .stroke(.black, lineWidth: 2)
under comment five and change it to:
.stroke(lineColors[index], lineWidth: 2)
Now you draw each line in a unique color. To finish the radar chart, you’ll draw the polygon connecting the ends of each measurement line. Since this view is a bit more complicated, add the following code to the end of the current file:
struct PolygonChartView: View {
var values: [Double]
var graphSize: Double
var colorArray: [Color]
var xCenter: Double
var yCenter: Double
var body: some View {
Path { path in
}
}
}
You created a new view that will encapsulate the polygon part of the view. Separating this into a separate view will improve readability while reducing clutter and problems with the SwiftUI compiler.
Now add the following new code to PolygonChartView
after the properties:
var gradientColors: AngularGradient {
AngularGradient(
colors: colorArray + [colorArray.first ?? .black],
center: .center,
angle: .degrees(-90)
)
}
You create an AngularGradient
and pass your colorArray
while appending the first color to its end. You do this to match the start and end colors of the angular gradient. Since the gradient starts toward the right, you set the angle
property to -90 degrees to rotate the gradient by a one-quarter revolution so it starts upward.
Now fill in the Path
closure in the view’s body
with the following code:
// 1
for index in values.indices {
let value = values[index]
// 2
let radians = Angle(degrees: 72.0 * Double(index)).radians
// 3
let x = sin(radians) * graphSize * value
let y = cos(radians) * -graphSize * value
// 4
if index == 0 {
path.move(to: .init(x: x, y: y))
} else {
path.addLine(to: .init(x: x, y: y))
}
}
// 5
path.closeSubpath()
Here’s how this code works:
- Since you’re inside a
Path
closure, you use the standard Swiftfor in
loop instead ofForEach
. You then get the value for the current iteration. - When plotting the values, you determine the angle of this measurement. You use the same 72 degrees angle and convert it to radians as you did before, since both
sin
andcos
expect radian values. - Earlier, you let SwiftUI rotate the lines, but you need to do it yourself in this view. To calculate the x and y values for a point of a specific length at a specified angle, you multiply the sine of the angle by the distance to calculate
x
. You multiply the cosine of the angle by the length to calculatey
. You use a negative value fory
because trigonometric functions assumey
increases upward and in a counter-clockwise direction while in SwiftUI angles increase clockwise andy
increases going down the view. - The first time through the loop, you need to use
move(to:)
to start the path. For the remainder of the shape, you calladdLine(to:)
to add the new point with a line back to the previous point. - To finalize the path, you call
closeSubpath()
on thePath
. This method draws a line back to the start of the path to close the polygon.
Now you’ll let SwiftUI handle the offset and apply the gradient. Add the following code as modifiers to the Path
you just added:
.offset(x: xCenter, y: yCenter)
.fill(gradientColors)
.opacity(0.5)
The first modifier offsets the Path
, so the center lies at the center of the view. You then apply the gradient and reduce the opacity, so the gradient colors don’t overwhelm the chart.
Finally, use PolygonChartView
in AnimatedRadarChart
by adding this piece of code at the end of the GeometryReader
:
PolygonChartView(
values: values,
graphSize: graphSize,
colorArray: lineColors,
xCenter: xCenter,
yCenter: yCenter
)
Your preview will now show the final chart using the sample data.
Now it’s time to integrate the new chart into your app.
Using the Radar Chart¶
Open TeaRatingsView.swift. Now add the following code at the end of the ZStack
in place of the comment reading // Add Radar Chart Here
:
AnimatedRadarChart(
time: Double(ratings[selectedRating].time),
temperature: Double(ratings[selectedRating].temperature),
amountWater: ratings[selectedRating].amountWater,
amountTea: ratings[selectedRating].amountTea,
rating: Double(ratings[selectedRating].rating)
)
.aspectRatio(contentMode: .fit)
.animation(.linear, value: selectedRating)
.padding(20)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(Color("QuarterSpanishWhite"))
)
You add the radar chart below the swipeable area. It’ll display the chart for the current rating through the selectedRating
index.
Run the app and select either Green Tea or Oolong Tea. Tap the Ratings area, and the app will show the radar chart. Change between them by swiping or tapping the indicator squares, and you’ll see the chart changes to match.
You might’ve noticed that despite the implicit animation applied when selectedRating
changes, there’s no animation. In the next section, you’ll learn how to animate a view with multiple properties using AnimatablePair
.
Animating the Radar Chart¶
When you need to animate a single value, you conform to the Animatable
protocol. This protocol uses a property named animatableData
that SwiftUI uses to pass the changing value into your view. In Chapter 6: Intro to Custom Animations, you set animatableData
to a Double
. So how can you manage five Double
s?
SwiftUI provides AnimatablePair
especially for these cases. As the name implies, it supports two values instead of a single value. For example, the following code would expect two animated values for a view:
AnimatablePair<Double, Double>
So how would you handle the five values needed in this view? By nesting AnimatablePair
s. Update the definition of AnimatedRadarChart
to:
struct AnimatedRadarChart: View, Animatable {
Now add the following code after the properties for the view:
// 1
var animatableData: AnimatablePair<
// 2
AnimatablePair<Double, Double>,
// 3
AnimatablePair<
// 4
AnimatablePair<Double, Double>,
// 5
Double
>
>
This code looks more complicated than it actually is. Here’s how this block of code lets SwiftUI pass in animated values:
- You begin by defining the
animatableData
property required by the Animatable protocol as a type ofAnimatablePair
. You then specify two values for the pair. - For the first element of the pair, you define another
AnimatablePair
, which takes twoDouble
values. EachAnimatablePair
has two properties namedfirst
andsecond
used to access their elements. This means thatanimatableData.first
now consists of anAnimatablePair
with elements you can access by`animatableData.first.first
andanimatableData.first.second
. - For the second element of the top level
AnimatablePair
, which you access throughanimatableData.second
, you define anotherAnimatablePair
. - The first element of this new
AnimatablePair
consists of anotherAnimatablePair
of twoDouble
s as the first element. - The second element of that last
AnimatablePair
is aDouble
. As you can see, this gives you a total of the fiveDouble
values that you need.
This diagram shows how the elements flow from each other and how to access each element. You can continue this pattern if needed, but as you can see, it’s pretty complicated at five values.
Next you’ll need to assign each of the values in the nested AnimatablePair
s to a property in the view. You want the first property in the view to match the first value in animatableData
.
Here’s a diagram showing how the design ties to specific values:
The getter and setter for the property then need to translate between these elements of the nested structures and the properties on the view. Add the following code directly after the declaration of animatableData
. The first line replaces the lone closing >
symbol in the last code block:
> {
get {
// 1
AnimatablePair(
AnimatablePair(time, temperature),
AnimatablePair(
AnimatablePair(amountWater, amountTea),
rating
)
)
}
set {
// 2
time = newValue.first.first
temperature = newValue.first.second
amountWater = newValue.second.first.first
amountTea = newValue.second.first.second
rating = newValue.second.second
}
}
Follow the diagram, and you’ll see how the data structure for the AnimatablePair
maps to the properties and the structure’s properties. Here are the specifics for the two methods:
- The getter for the property needs to return a value matching the complicated structure you defined earlier. You create a series of nested
AnimatablePair
types with the values set as shown in the diagram. - Setting the view’s properties from the
AnimatablePair
requires you to navigate thefirst
andsecond
properties.
While complicated, this code wraps up everything needed to animate the view. Run the app and select either Green Tea or Oolong Tea. Tap the Ratings area, and the radar chart shows each rating when selected. As you change between the ratings, you’ll see the view now animates between the charts.
Key Points¶
- Transitions are a type of animation. When combining transitions, you’ll find it easier to use different state changes to control each individually.
- You can apply an animation to a transition that will set the transition’s animation curve and duration.
- A radar chart provides a way to visualize the relationship between multiple related values.
- You can use the
AnimatablePair
type when you need to animate multiple values in a view that conforms to theAnimatable
protocol. - If you need to animate more than two values, you can nest multiple
AnimatablePair
structures within each other. While this can quickly become complicated, it’ll let you support many values.
Where to Go From Here?¶
- For more about the relationship between angles and trigonometric methods, see Core Graphics Tutorial: Arcs and Paths.
- The new Swift Charts API handles many cases and supports animation, though not the radar chart used here.
- For more examples of creating charts in SwiftUI without the Swift Charts API, see SwiftUI Tutorial for iOS: Creating Charts.